Skip to content

Conversation

rasendubi
Copy link
Collaborator

@rasendubi rasendubi commented Mar 20, 2025

See documentation for a bit more context.

  • Make Configuration a passive/immutable piece of data that encapsulates flags/bandits/etc
  • Split current configuration store into two components:
    • ConfigurationStore as a central piece that just tracks the currently active configuration (in memory). The rest of components integrate with ConfigurationStore and communicate via getting/setting/listening for Configuration (see picture)
    • Persistence component with two features: loading initial configuration and persisting subsequent configurations (in background)
  • Configuration feed a simple observer for components to learn about new configuration (that are not necessarily activated)

TODO:

  • Integrate persistence (race initial load in init, store configurations on change)
  • cleanup
  • fix tests
  • fix precomputed client 🤕
  • updates to client and node sdks

configuration-lifecycle excalidraw

@rasendubi rasendubi requested a review from typotter March 20, 2025 16:09
Copy link
Collaborator

@typotter typotter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love seeing more code deleted than added, yet new features developing from it.

@@ -0,0 +1,5 @@
{
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹

}

/** @internal */
type ConfigurationWire = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

heads up, IConfigurationWire is already defined here

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I tried using it but ran into it having readonly fields, so I couldn't construct it like this:

    const wire: ConfigurationWire = {
      version: 1,
    };
    if (this.flags) {
      wire.config = {
        ...this.flags,
        response: JSON.stringify(this.flags.response),
      };
    }
    // ...

It's also quite a bit more convoluted with braided strings and class implementation, etc.

So my idea is to have the simplest definition here, just for sanity check—it's not exported and is not used for anything but Configuration.toString()/Configuration.fromString(). (The design idea is that it's just a string representation of Configuration.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe call it ConfigurationWireStrings then? 😅

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why delete?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the BaseEppoClient would actually provide this functionality.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, ConfigurationRequestor is basically doing the same thing now with the new fetchConfiguration() method (when paired with Configuration.toString())


/** @internal
*
* Returns flag configuration for the given flag key. Obfuscation is
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💪

@@ -14,7 +15,7 @@ import {
Allocation,
Split,
VariationType,
ConfigDetails,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎊

);
const configFormat = flagsConfig?.response.format;
const obfuscated = configFormat !== FormatEnum.SERVER;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should isObfuscated be a method on Configuration so we're not leaking the FormatEnum details?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isObfuscated() is oversimplification in a sense that Configuration contains multiple pieces (flags, bandits, precomputed), where each could be obfuscated, not obfuscated, or simply missing. So a single isObfuscated() method doesn't cut it.

It also spreads this oversimplified model in programmers' brains, so we're more likely to miss some edge cases. I would rather have us working with raw data instead of half-baked abstractions. It may not be as pretty (initially) but at least it's obvious what's going on

re leaking details: my philosophy is that SDKs are in the know. Evaluation is highly dependent on flags response type, so there's no sense hiding it. When talking about leaking details, the only meaningful distinction for me is user–SDK. Configuration is exposing full server responses but is marking them @internal for this reason

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re FormatEnum specifically: it's currently mixing formats from two independent and unrelated configuration pieces (flags and precomputed), that actually have different rules for determining obfuscation

Next refactoring step is splitting it into FlagsConfigurationFormat and PrecomputedConfigurationFormat. And then we can have a proper isObfuscated(FlagsConfigurationFormat): boolean helper to encode this piece of knowledge

Longer-term still, we shall add isObfuscated field to flags configuration

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to handle differently formatted flags and bandits and whatnot. If bandit format doesn't match flags we could just throw an error or something.

return undefined;
}
return {
response,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great steps towards passing and checking the eTag in this SDK

Comment on lines +1242 to +1243
obfuscated:
this.getConfiguration()?.getFlagsConfiguration()?.response.format === FormatEnum.CLIENT,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

obfuscated computation should move somewhere centralized

@typotter typotter self-requested a review April 7, 2025 14:26
src/broadcast.ts Outdated
}
}
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: newline

/**
* Enumeration of possible configuration sources.
*/
export enum ConfigurationSource {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about initial/offline/bootstrap?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is considered as "cache" but I think it makes sense to add "User" type as well

@@ -21,6 +21,8 @@ import { Environment } from '../interfaces';
*
* The policy choices surrounding the use of one or more underlying storages are
* implementation specific and handled upstream.
*
* @deprecated To be replaced with ConfigurationStore and PersistentStorage.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we not able to remove these entirely?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll be able to once I finish precomputed client migration


/**
* ConfigurationCache is a helper class that subscribes to a configuration feed and stores latest
* configuration in persistent storage.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also loads from storage and publishes to the feed

private readonly storage: PersistentConfigurationStorage,
private readonly configurationFeed?: ConfigurationFeed,
) {
configurationFeed?.addListener(async (configuration, source) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to worry about cancelling the listener?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In what situation would you cancel the listener?

* has proper versions for all bandits references in flags
* configuration.
*/
const banditsUpToDate = (flags: FlagsConfig, bandits: BanditsConfig | undefined): boolean => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯 I like the inversion here over what we've coded this as (needToFetchBandits). This makes it much more clear.

* The method is allowed to do async work (which is not awaited) or
* throw exceptions (which are ignored).
*/
storeConfiguration(configuration: Configuration | null): PromiseLike<void>;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't finished reading all the code yet but something to call out here if it's not already integrated, is the need for the persistent store to be keyed by API key (or some other function thereof) so that a single storage subsystem can store config for multiple api keys simultaneously.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's best handled by individual constructors. We don't allow changing sdk key on the fly, so a persistent store instance is always used with the same key

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the upstream creation have a way to not have these configurations clobber each other if different keys are used?

Comment on lines +20 to +22
"./internal": {
"types": "./dist/internal.d.ts",
"default": "./dist/internal.js"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Want to clearly delineate public and internal APIs, so @eppo/js-client-sdk-common/internal is intended for internal SDK usage only and should not be re-exposed to users.

* If the client is configured to precompute, returns a Subject-scoped instance for the
* precomputed configuration.
*/
public getPrecomputedSubject(): Subject | undefined {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API is replacing a dedicated PrecomputedClient

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It still feels like a "client" to me as it will be doing assignment logging and other stuff beyond the scope of a "subject"

Perhaps we should call these things something like getClientForSubject() or getSubjectClient(), getPrecomputedSubjectClient(), etc.,

Comment on lines +1430 to +1442
const precomputed = config.getPrecomputedConfiguration();
if (precomputed && precomputed.subjectKey === subjectKey) {
// Short-circuit evaluation if we have a matching precomputed configuration.
const precomputedResult = this.evaluatePrecomputedAssignment(
precomputed,
flagKey,
expectedVariationType,
);

return precomputedResult.flagEvaluation;
}

const flag = config.getFlag(flagKey);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EppoClient now automatically handles precomputed assignments if precomputed configuration is present for the specified subject key

Comment on lines +6 to +13
/**
* A wrapper around EppoClient that automatically supplies subject key, attributes, and bandit
* actions for all assignment and bandit methods.
*
* This is useful when you always want to use the same subject and attributes for all flag
* evaluations.
*/
export class Subject {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is subject-aware client

Initially named it subject-bound client but then was debating with myself whether I should duplicate ALL public API from client or just evaluation methods. We can always expose more methods later, so decided to only implement evaluation ones. Then realized that we can name it "Subject" and have getPrecomputedSubject() on EppoClient, so that seemed like a neat idea

}

public getConfiguration(): IConfiguration {
return this.configuration;
public async fetchConfiguration(): Promise<Configuration> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchConfiguration() now returns full Configuration object

ConfigurationRequestor is now also able to handle both regular and precomputed requests

*
* @public
*/
export class Configuration {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the boss

export interface FlagEvaluation {
assignmentDetails: AssignmentResult;
assignmentEvent?: IAssignmentEvent;
banditEvent?: IBanditEvent;
}

export class Evaluator {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO for the future (non-breaking): evaluation is currently spread thin between EppoClient and Evaluator. It would make a ton of sense for Evaluator accept configuration storage as input and handle all evaluation

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you talking about the checks done in getAssignmentDetail()? Agree that could be brought out of the client.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Checks, handling of overrides, disabled flags, etc.

/**
* Simple key-value store interface. JS client SDK has its own implementation based on localStorage.
*/
export interface KVStore<T> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is old IAsyncStore — renamed and removed isInitialized() as it was unused. It is only used for overrides store now

import { ContextAttributes, FlagKey, HashedFlagKey } from './types';

// Base interface for all configuration responses
interface IBasePrecomputedConfigurationResponse {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no changes to types here — just moved from configuration-wire-types

*
* @internal
*/
export function generateSalt() {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually found that SDK was not generating salt and getPrecomputedConfiguration was asking user to pass salt (which is a bad idea as I guess it was never passed)

@rasendubi rasendubi changed the title refactor: new ConfigurationStore refactor: JS SDK v5 Apr 23, 2025
@@ -0,0 +1,70 @@
# Configuration Lifecycle

This document explains how configuration is managed throughout its lifecycle in the Eppo SDK.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥


1. **Configuration Loading Strategy**:
- `stale-while-revalidate`: Uses cached config if within `maxStaleSeconds`, while fetching fresh data
- `only-if-cached`: Uses cached config without network requests
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

even if stale? If so we should put that in

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, no, maxStaleSeconds is still respected

During initialization, the client:

1. **Configuration Loading Strategy**:
- `stale-while-revalidate`: Uses cached config if within `maxStaleSeconds`, while fetching fresh data
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where do we specify what we do with the fetched data? Save for next time? Switch immediately?
I see that is covered later in "Configuration Activation" perhaps we should specify that here. "...while fetching fresh data to be activated per the configuration activation strategy"

4. **Completion**:
- Initialization completes when either:
- Fresh configuration is fetched (for network strategies)
- Cache is loaded (for cache-only strategies)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or stale-while-revalidate right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For stale-while-revalidate, the initialization completes when a fresh configuration is fetched

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I thought that strategy means serve stale assignments and fetch and background? I think that would mean initialization should finish prior to fetch.

*
* @internal
*/
export class BroadcastChannel<T extends unknown[]> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason you're rolling your own vs. using a multi-js-environment third party library like eventemitter3?

This seems simple enough and keeps dependencies down, which are advantages so I'm cool rolling with this just curious if you had thoughts about the pros and cons.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wanted it to have proper typescript support and be lightweight (eventemitter3 is neither + unmaintained). Couldn't find a good library quickly, so it was easier to roll my own

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense!

Comment on lines +277 to +280
if (banditKey) {
return this.parts.bandits?.response.bandits[banditKey] ?? null;
}
return null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could combine to simply

return banditKey && this.parts.bandits?.response.bandits[banditKey] ?? null;

}

/** @internal */
type ConfigurationWire = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe call it ConfigurationWireStrings then? 😅

export interface FlagEvaluation {
assignmentDetails: AssignmentResult;
assignmentEvent?: IAssignmentEvent;
banditEvent?: IBanditEvent;
}

export class Evaluator {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you talking about the checks done in getAssignmentDetail()? Agree that could be brought out of the client.

);
const configFormat = flagsConfig?.response.format;
const obfuscated = configFormat !== FormatEnum.SERVER;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to handle differently formatted flags and bandits and whatnot. If bandit format doesn't match flags we could just throw an error or something.

* The method is allowed to do async work (which is not awaited) or
* throw exceptions (which are ignored).
*/
storeConfiguration(configuration: Configuration | null): PromiseLike<void>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the upstream creation have a way to not have these configurations clobber each other if different keys are used?

Comment on lines +14 to +20
## Communication Flow

The ConfigurationFeed acts as a central hub through which different components communicate:

![](./configuration-lifecycle.excalidraw.png)

When a new configuration is received (either from network or cache), it's broadcast through the ConfigurationFeed. Components subscribe to this feed to react to configuration changes. Importantly, configurations broadcast on the ConfigurationFeed are not necessarily activated - they may never be activated at all, as they represent only the latest discovered configurations. For components interested in the currently active configuration, the ConfigurationStore provides its own broadcast channel that only emits when configurations become active.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a really exciting new interface - something I want to support in the near future is a concept that the requested configuration is not going to be "complete": we might want to deliver updates via a websocket or the edge JSON will be paged. Either way having a separation between the network response being the canonical source of truth and the configuration store is very important.

Do you feel like the interface you are creating makes us read to push updated in this way? I am wondering if the broadcaster should have a more verbose API that tells listeners whether flags are being added or removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants